# 实战篇 06:消息通知设计

本节参考代码:
react-boilerplate-pro

在传统的企业管理系统中一个经常被忽略掉的细节就是对于用户操作的反馈。一方面是因为复杂的业务流程边界条件太多,每一个分支都给予适当的反馈是一件非常烦琐的事情。另一方面往往是因为系统架构设计得不够灵活,只是显示一个操作成功或失败的通知就要写大量重复的逻辑代码,导致专注于业务流程的开发者不愿意花时间去处理操作反馈这一“可有可无”的需求。

在操作反馈通知(Notification)之外,完善的企业管理系统还需要一个全局的消息系统以方便系统管理员向某些或全部成员发送系统消息(Notice),即应用页眉中的通知栏,如 ant-design-pro 中的例子。

# 全局通知栏与应用初始化

与前端主导的通知系统不同,系统消息更依赖后端的实现。这里我们假设后端提供了获取当前用户未读消息的接口 /notices,创建相应的 redux action 为:

const getNotices = () => (
  createAsyncAction('APP_GET_NOTICES', () => (
    api.get('/notices')
  ))
);

# 应用数据初始化

在实现系统消息之前,我们首先要解决应用数据初始化的问题。以系统消息为例,用户在登录系统后点击应用页眉中的通知栏就应当立刻可以看到当前的未读消息。但获取用户未读消息是一个异步的过程,这里的异步请求应该在什么时候发出呢?

最简单的解决方案当然是在用户点击通知栏时发出,但这样做的弊端是用户在阅读消息前需要等待时间,没有充分利用到用户登录系统后但未点击通知栏这段空闲时间,而且如果用户频繁点击通知栏的话还会导致大量的冗余异步请求被发送至后端。

另一个解决方案就是在用户成功登录后发送请求去取得最新的未读消息:

const login = (username, password) => (
  createAsyncAction('APP_LOGIN', () => (
    api.post('/login', {
      username,
      password,
    })
  ))
);

const loginUser = (username, password) => {
  const action = login(username, password);

  return dispatch => (
    action(dispatch)
      .then(((callbackAction) => {
        if (callbackAction.type === 'APP_LOGIN_SUCCESS') {
          return getNotices()(dispatch);
        }
        return null;
      }))
  );
};

但这样做会有一个例外就是因为系统会记录用户的登录状态,在鉴权过期前用户刷新页面是不需要重新登录的。这时我们就需要在系统初始化时,判断如果用户已经登录就发送获取系统消息的请求。

const initClient = (dispatch) => {
  const commonActions = [
    dispatch(appAction.getLocale()),
  ];

  const isLogin = !isNil(Cookie.get('user'));

  if (isLogin) {
    commonActions.push(dispatch(appAction.getNotices()));
  }

  return commonActions;
};

并在应用的入口文件中将 redux 的 dispatch 方法传入 initClient

const { store, history } = createStore(createBrowserHistory(), {});
const application = createApp(store, history);

initClient(store.dispatch);
ReactDOM.render(application, window.document.getElementById('app'));

按照同样的逻辑,我们可以将这一解决方案拓展到更多的需求,如在应用初始化时获取用户信息、国家时区信息等。

# 更新消息列表

在解决了数据获取的问题后,我们的布局组件就可以直接访问 redux store 中 app reducer 下的 notices 数据了。这里关于把 notices 存在哪个 reducer 中可能会有争议,比如它可以属于 app 的 reducer,即存放全局数据的地方,也可以单独创建一个 basicLayout 的 reducer 来存放布局组件所需要的数据,两种方案都是可行的。

接下来我们要实现的需求是在用户点击某一条消息后将其从消息列表中删除,因为系统消息列表是由后端控制的,所以这时我们需要向后端发送一个 DELETE 的请求以删除当前用户点击的某条消息。这时又会出现一个分歧是,后端在接收到 DELETE 请求后会不会将最新的消息列表再返回给前端。如果后端能够返回的话,我们只需要在 reducer 中替换掉原先的消息列表即可,但如果后端只返回操作成功或失败的话,我们还需要再发送一遍 getNotices 请求去拉取最新的消息列表。

最后,我们还需要处理无未读消息时的情况。

# 全局通知

回到一开始提到的操作反馈部分。

如果系统中的每个页面都需要独立去处理操作反馈的话,可以预见的是 <Notification /> 组件几乎会出现在所有的页面。这样的解决方案不仅非常烦琐而且不利于统一处理通用的逻辑。

既然通知组件可以使用绝对定位的方式出现在不同页面的同一位置,那么我们能不能将它的显示和隐藏逻辑也放在全局的层面上进行处理呢?

在回答这个问题前,我们先来写一个简单的基于绝对定位、支持自动隐藏的 UI 通知组件。

class Notification extends Component {
  componentDidMount() {
    this.timeout = setTimeout(this.props.onDismiss, this.props.timeout);
  }

  componentWillUnmount() {
    clearTimeout(this.timeout);
  }

  render() {
    const {
      prefixCls,
      title,
      content,
      onDismiss,
    } = this.props;

    return (
      <div className={prefixCls}>
        <Icon className={`${prefixCls}-close`} type="close" onClick={onDismiss} />
        <div className={`${prefixCls}-title`}>{title}</div>
        <div>{content}</div>
      </div>
    );
  }
}

从上述代码中可以看出,onDismiss 回调就是为使用者隐藏全局通知时准备的。为了在全局的层面上解决通知的问题,让我们在 app reducer 中为通知提供一个存放数据的位置,暂且称为 notification,其中包含 titlecontent 两个字段。

// app reducer
const defaultState = () => ({
  isLogin: false,
  user: {},
  notification: {
    title: '',
    content: '',
  },
});

// app action
const updateNotification = notification => ({
  type: 'APP_UPDATE_NOTIFICATION',
  payload: notification,
});

const resetNotification = () => ({
  type: 'APP_RESET_NOTIFICATION',
});

// app reducer
const updateNotification = (state, action) => ({
  ...state,
  notification: action.payload,
});

const resetNotification = state => ({
  ...state,
  notification: {
    title: '',
    content: '',
  },
});

再将通知的渲染逻辑添加到 BasicLayout 布局组件中:

renderNotification = () => {
  const { notification: { title, content }, resetNotification } = this.props;
  if (isEmpty(title) && isEmpty(content)) {
    return null;
  }
  return (
    <Notification title={title} content={content} onDismiss={resetNotification} />
  );
}

即如果 notificationtitlecontent 字段都不为空的话,就显示全局通知。同时我们也将重置通知的 action 配置给了 Notification 组件的 onDismiss 回调,即关闭通知相当于重置 notification 字段为空对象。

等这些准备工作都做好后,在具体的页面中显示通知就变得非常容易了:

const mapDispatchToProps = {
  updateNotification: appAction.updateNotification,
};

<Button
  type="primary"
  onClick={() => this.props.updateNotification({
    title: 'Notification Title',
    content: 'Notification will dismiss after 4.5s.',
  })}
>
>	
</Button>

将 app action 中的 updateNotification 函数 connect 至相应的页面组件即可。然后在这个页面中,使用者就可以直接调用 this.props.updateNotification 来显示相应的通知了。又因为通知组件本身就支持自动隐藏的功能,使用者也不再需要去处理隐藏的逻辑。

# 知识点:数据驱动视图

全局通知这个例子很好地为我们诠释了「数据驱动视图」的含义,即根据数据中心是否存在 notification 对象来决定是否渲染通知组件。这打破了原先显示或隐藏通知这样命令式的代码逻辑,让每一次的用户操作从执行命令变为了修改数据,然后再由更新后的新数据去驱动视图进行相应的更新。

熟悉 React 的朋友一定看过下面这个公式:

view = f(state)

即视图是由当前应用状态推导而来。我们再尝试将 redux 也包含进来,在这个公式的基础上进行一下拓展。

根据

view = f1(state) && nextState = f2(currentState, action)

可以推导出

view = f1(f2(currentState, action))

对应到全局通知的例子,f2 就是 app reducer 中处理数据的逻辑,而 f1 就是 BasicLayout 中的渲染逻辑。有了这两个函数,currentState 是确定的一份数据,在具体 action 的驱动下视图就可以自动地进行更新。

因为视图的不可枚举性(无限种可能),命令式的编码方式一直以来都非常不适合前端应用的开发,因为它会导致非常多的边界情况且不可测试。在传统的前端开发中我们很难说出「在什么情况下视图一定是什么样」这样的话,但根据上面的公式推导,如果我们善加利用 React + Redux 的特性的话前端开发也是可以有底气做到「视图结果可预测」的。

# 组合式开发:消息通知

组合式开发从本质上来说应用的是分层的思想,合理的分层可以明显地降低应用复杂度并且在应用出现问题时也可以帮助开发者快速定位问题发生的位置。而在分层固定下来后,开发新需求时第一个要去考虑的问题就是新需求应当被放在应用的哪一层去解决。如这一小节中的操作通知,如果被错误地放在页面层去处理的话就会导致许多冗余的代码,以及出现问题时页面的其他逻辑和操作通知的显示逻辑互相混淆,而放在应用层处理就可以帮助页面层屏蔽掉这些问题。

另一方面,在应用层增加了「显示操作通知」这样一个 action 后,对于其下属的页面层来说相当于是增加了一种「显示操作通知」的能力,是否要使用这种能力以及何时使用这种能力的决定权是在页面层手中的。也就是说在增加了这样一种能力之后并没有加重页面层的负担,页面层可以自己决定在适当的时候使用这种能力,而不需要担心不使用这种能力会带来任何的副作用。这种无副作用的特性也从另一个侧面解释了组合式开发中的可插拔性。

# 小结

在本节中我们以系统消息为例引出了应用数据初始化的解决方案,并结合 React + Redux 实现了一套基于数据驱动的全局操作通知系统,简化了在页面组件中显示全局通知的逻辑。

在下一节中我们将会探讨需要支持多种语言的前端应用架构。

如果你想参与到文章中内容的讨论,欢迎在下面的评论区留言,期待与大家的交流。

阅读全文